공통으로 사용할 수 있는 모달을 제공해보자! 👍
씨젠 프로젝트에서 아키텍처 뿐만아니라 공통부분도 업무로 맡았다. 사실, 처음부터 공통을 해야겠다라는 생각은 없었다.
전체적인 개발 디자인패턴을 구상하고 설계를 하면서 자연스레 공통부분도 시선이 갈 수밖에 없었다.
내가 설계한 것을 누군가에게 설득시키기 위해선 결국 실제 적용된 소스들이 얼마나 효율적으로, 경제적으로 이루어 질 수 있는가가 뒷받침되어야한다.
아무리 좋은 구상이 있다고 한들 추상적일 뿐이다. 추상화를 구현을 해야 설계는 그 때 비로소 큰 힘을 발휘하게 된다.
내가 설계한 것을 바탕으로 구현하고 싶은 것이 무엇이 있을까 생각했을때 공통모달이 떠올랐다. 모달은 모든페이지에서 사용하는 팝업창이기 때문에
사용하는 로직이 간단해질 수록 작업효율은 올라갈 수 밖에없다.
왜 직접구현을 하게되었을까?

위의 사진만 보더라도 모달기능은 이미 라이브러리로 많이 개발되어있다. 단순히 위의 모달을 설치하여 붙이면 쉽겠지만 이번 프로젝트에선 실제로 직접구현해보고 싶었다. 직접 구현해보고 싶은 이유는 크게 두가지이다.
첫번째는, 커스텀마이징이 쉬워진다. 구축단계에서 프로젝트는 대부분 기획이 복잡한 부분이 많다. 기획이 복잡해지면 커스텀마이징이 필수적으로 들어간다.
단순히 모달을 생각하면, 팝업창을 오픈하고 팝업창안에서 컨텐츠를 보여주고, 클로즈하는 단순한 로직이 떠오르지만 이미 다양한 프로젝트를 3년동안해온 나로서는 예상이 충분이 되었다. 모달안에서 복잡한 로직이 숨겨져 있음을..
두번째는, 이미 리액트에서 제공하는 api createPortal이 있어서 이해하고 개발함에 있어 더 쉬워질 수밖에 없었다.
실제로, mui를 사용하기 때문에 createPortal를 사용할 일은 없지만, 결국 mui dialog도 리액트에서 제공해주는 api를 이용했을 거기 때문에 개발함에 있어서 쉬워질 수밖에 없었다.
이미 좋은재료가 있는데 굳이 배달음식을 시켜먹어야할까? 라는 생각이 절로 들 수 밖에없는 것이다!
1단계 구상하기 ⏰
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body suppressHydrationWarning={true}>
{children}
<ModalComponenet />
</body>
</html>
);
}지금 프로젝트는 next13을 기반으로 구축하고 있다. 그래서 크게 layout파일에 children이 들어가게된다. 칠드론에선 해당 경로에맞는 페이지영역이 들어가게된다. 모달은 칠드론을 구상하는 모든 페이지에서 제일 우선권을 가지게 되므로, 페이지 안에 있는게 아니라 레이아웃에서 구상을 해야한다.
위의 코드처럼 칠드론 밑에 구성을 하여 모달을 제공했다.

다음은, Presentational & Container Component Pattern 과 Custom Hook 을 조합하여 개발을 진행하였다.
-
Modal UI (Presentational Component)
-
기능적인 측면은 제외하며, 모달의 UI만 담당
-
공통모달의 디자인 케이스에 따라 제공
ex) 사이즈, 색상, 모달의 종류
-
-
Custom Hook
- 모달을 사용하기위한 반복적인 스크립트 소스들을 훅으로 제공하여 반복을 줄인다.
- 모달에 필요한 데이터들을 받아서 UI에게 전달하는 역할을 한다.
-
Container Component
- 비지니스 로직을 담당한다.
- 실제 모달을 사용하는 곳에서 커스텀훅을 이용하여 데이터를 주고받는다.
위와같이 크게 3단계로 나누어서 생각하였고, 이렇게 분리함으로서 기능과 UI를 명확하게 제공하여, 코드가 복잡해질 수록 가독성이 크게 개선되고, 유지보수가 용이해진다. css적인 이슈가 있을땐 모달컴포넌트, 기능적인 측면은 커스텀훅, 비지니스로직 이슈일땐 컨테이너 컴포넌트에서 찾으면 된다.
예를들어보자, 실제로 개발운영을 하고있는 상태에서 이슈가 생겼다. 모달의 디자인이 변경되었다. 우리는 어디만 생각하면 될까?
바로 Modal UI를 생각하게 될 것이다. 하지만 위와같은 형태가아니라면? 우리는 해당 모달에 사용하는 로직을 이해하며 하나하나 경로를 찾아가야한다.
명확하다 라는 의미는 바로 이처럼 정확하게 내가 원하는 바를 찾게 해준다.
실제로 전에 있었던 프로젝트에서 운영으로 투입되었는데, 카드의 문구를 수정하는 간단한 업무였는데, 이를 찾는데 상당한 시간이 걸렸다. 그 때 얼마나 많은 현타가 왔는지 모른다.
useModal
import { useCallback } from 'react';
import { atom, useAtom } from 'jotai';
export type ModalType = {
title?: JSX.Element | string; // 타입 추가
content: JSX.Element | string;
textTitle?: JSX.Element | string; // 타입 추가
maxWidth?: 'xs' | 's' | 'm' | 'l' | 'xl' | 'hero'; // 사이즈 기준 추가
};
const modalState: ModalType = {
isOpen: false,
};
const modalAtom = atom(modalState);
export const useModal = () => {
const [modalDataState, setModalDataState] = useAtom(modalAtom);
const modalOpen = useCallback(
({ ...modalState }: ModalType) => {
setModalDataState(() => {
return {
isOpen: true,
...modalState,
};
});
},
[setModalDataState]
);
const modalClose = useCallback(() => {
setModalDataState({
isOpen: false,
});
}, [modalDataState, setModalDataState]);
return {
modalDataState,
modalOpen,
modalClose,
};
};내가 제일 고려했던 부분이 useModal이다. 모달에서 필요한 로직은 다음과 같다.
- Modal open : 모달 팝업을 열어주는 기능
- Modal closed : 모달 팝업을 닫는 기능
- title / content / maxWidth등 모달에 필요한 것들에 대한 데이터를 보관하여 모달컴포넌트에 전달
위의 스크립트를 보면, jotai로 상태관리를 제어하였다. 전역으로 사용하기 때문에 기존 useState를 사용하면 컴포넌트까지 전달되지 않는다. modalOpen에서 모달에 필요한 state들을 받아서 modalAtom에 보관한다. 그리고, modalClose를 통해 열려있던 모달을 초기화시켜 닫아버린다.
모달 컴포넌트 구성
export const ModalComponenet = () => {
return (
<>
{modalDataState &&
classes={{ paper: `${styles.modal_box}` }}
className={`${styles.modal}
fullWidth
open={modalDataState.isOpen || false}
onClose={() => modalClose()}
>
<IconButton className={styles.modal_close} aria-label="close" onClick={() => modalClose()}>
<Icons.CloseIcon width={24} height={24} fill="#C6CDDA" />
</IconButton>
<DialogTitle`}>
{modalDataState.title}
{modalDataState.textTitle && <p className={styles.text_title}>{modalDataState.textTitle}</p>}
</DialogTitle>
<DialogContent className={`${styles['modal_cont']}}>{modal.content}</DialogContent>
</Dialog>
))}
</>
}모달컴포넌트는 다음과 같이 구성하였다. mui에서 제공해주는 Dialog 컴포넌트로 구성하였다. 모달컴포넌트의 제일 핵심은 바로 사용범위성을 고려하는 것이다. 공통컴포넌트에서 제일 경계하는 것이 있는데, 바로 props의 갯수증가와 그에 따른 분기처리이다.
공통컴포넌트는 어느 페이지에서도 사용할 수 있어야한다. 하지만 본인이 사용하는 페이지에서 자주 보이는 UI라고 하여 그것을 공통으로 만든다고 한다면, 분기처리를 해야하는 번거로움이 생긴다. 그렇게 하나 둘씩 쌓이다보면 어느새 공통컴포넌트는 if문이 도배가 될 것이다. 그래서 컨텐츠영역에선 어떤 디자인이 오는지 예측할 수 없으므로 children으로 컴포넌트를 직접 받아서 비지니스로직을 실제 구현하는 곳에서 로직을 자유로이 구성할 수 있도록 하였다.
Container Component 구현
const Default = () => {
const { modalOpen, modalClose } = useModal();
const modalState: ModalType = {
title: '타이틀이 있을경우',
textTitle: '타이틀 하단의 텍스트가 존재시 사용합니다.',
icon: 'info',
maxWidth: 'xl',
content: (
<>
<div>컨텐츠가 들어가는 공간입니다.</div>
<div className="align center mt_30">
<ButtonMui type="outlined" size="xlarge" onClick={() => modalClose()}>
취소
</ButtonMui>
<ButtonMui size="xlarge">버튼</ButtonMui>
</div>
</>
),
};
return <button onClick={() => modalOpen(modalState)}>Open dialog</button>;
};비지니스로직을 구현하여 모달을 호출하는 컴포넌트이다. useModal 훅을 사용하여 modalOpen과 modalClose를 호출한다.
훅에서 제공하는 ModalType을 이용하여 사용자가 타입을 유추하기 쉽도록 제공하였다. content는 필수값이므로 꼭 넣어주어야하며,
modalState에 필요한 모달로직을 커스텀한다. 그리고, 버튼을 눌렀을 때 오픈한다고 가정하였고, onClick이 되었을때 모달오픈함수에 modalState
객체가 전달되어 useModal훅 안에 있는 modalOpen이 실행되게 된다. 실행이 된 다음, 모달컴포넌트에서 store에 변화를 감지하여 데이터가 변경된다. isOpen이 false에서 true가되어 모달이 화면에서 띄워지게되고, content에서 취소버튼을 제공하여 modalClose 함수를 실행하게된다.
modalClose를 실행하면 위의 동작원리와 동일하게 실행되어 모달이 닫히게된다.

후기 🙏
모달에서 보통 취소버튼과 저장버튼이 있는경우가 많아서 버튼부분도 공통으로 구성하면 어떨까 생각했지만, 결국 컨텐츠에 직접 넣는걸로 변경했다.
그 이유는 위에서 설명했던 것처럼 비지니스 로직이 다양하기 때문에 버튼이 하나인 경우도있고, 버튼 텍스트가 다른 경우, 저장이나 취소시 로직이 다른경우가 있기때문에 호출하는 컴포넌트에서 직접 비지니스 로직을 만들어서 넘겨주는 방식이 맞다고 생각했다.